feat: record judge scores as gen_ai.evaluation.result - #43
feat: record judge scores as gen_ai.evaluation.result#43ccschmitz-launchdarkly wants to merge 7 commits into
Conversation
Judge scores land as a `gen_ai.evaluation.result` span event (and mirrored attributes) on the judge `invoke_agent` span, so conversation turn badges can render. `with_judge_evaluation` holds that span open until scoring finishes — `execute_and_track` returns after the handler has already called `span.end()`, so without the delay the event would be dropped. Existing `track(evaluation_metric_key)` behavior is unchanged. TELEMETRY-CONTRACT.md section 4a documents the event contract. Stacked on the conversation-id PR: the two halves share conversation.py and nothing else, so they review apart and merge together. O11Y-1888 Co-Authored-By: Claude <noreply@anthropic.com>
…Y-1888-judge-evals # Conflicts: # packages/client/tests/test_conversation.py
…nd time Review follow-ups on the judge half. - Do not emit `gen_ai.evaluation.explanation`. The judge's reasoning is model-generated prose about the user's conversation — content — and AGENTS.md gates content attributes behind `capture_content`, a handler-factory option this layer never receives. It was exported unconditionally, including for callers who left capture off. The reasoning is still returned to the caller in `judge_results`; only the telemetry copy is withheld. - Replace `float(score)` with a non-raising finite-number guard. `float()` was a new raise site sitting ahead of the existing `client.track(evaluation_metric_key, …)` call, so a judge returning "0.9 (high)" silently killed the metric this PR claims is unchanged — and in `run_judge` it escaped uncaught, breaking that function's documented "returns None" contract. - Freeze the end time when the handler calls `end()`. Replaying a no-arg `end()` at release let the SDK stamp `time_ns()` then, inflating every judge span by the tracking and parsing work that runs in between. - Detach the judge capture even if the deferred end raises. O11Y-1888 Co-Authored-By: Claude <noreply@anthropic.com>
…Y-1888-judge-evals
One run that exercises everything O11Y-1888 touches: three turns bound to a single conversation id, each with inline judge evaluation. Prints the id so it can be opened directly in the Conversations view. It is also the manual check for the content decision — the judge's reasoning is printed from `judge_results` (the caller's copy) and should appear nowhere in the exported telemetry. O11Y-1888 Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 005ce8e. Configure here.
| print( | ||
| f"[judge] {judge_key} score={score} reasoning={reasoning}", | ||
| file=sys.stderr, | ||
| ) |
There was a problem hiding this comment.
Judge example reads dicts as objects
Medium Severity
The new conversation example prints judge score and reasoning via getattr on each judge_results value. Inline judges still store plain dicts (score / response keys), so those attributes are missing and the example always prints None even when a judge ran. That makes the O11Y-1888 end-to-end check look like it had no scores.
Reviewed by Cursor Bugbot for commit 005ce8e. Configure here.
The judge's reasoning was removed outright on PII grounds. That was the right instinct in the wrong shape: the conversation view reads `gen_ai.evaluation.explanation` for its badge tooltip and evaluation banner, so dropping it left a field the UI is built to display permanently blank. Gate it instead, like every other content attribute: - `ProviderHandler` now carries `capture_content`, set by `create_handler` alongside `provides_for`. The client core can apply the handler's own content decision to content it writes on the handler's behalf, without reaching into the factory's closure. - All six handler factories pass their flag through. - `run_judges` / `run_judge` forward the reasoning only when the judge's own handler captures content. Capture on: the explanation reaches the span and the UI renders it. Capture off: absent, exactly as before this commit — no ungated leak. O11Y-1888 Co-Authored-By: Claude <noreply@anthropic.com>


Summary
Judge scores land as a
gen_ai.evaluation.resultspan event (plus mirrored span attributes) on the judge'sinvoke_agentspan, so conversation turn badges can render.with_judge_evaluationholds the judgeinvoke_agentspan open until scoring finishes.execute_and_trackreturns after the handler has already calledspan.end(), so without the delay the event would be dropped.gen_ai.evaluation.nameandgen_ai.evaluation.score.value, mirrored as span attributes.score.labelis not invented, and only a finite number is recorded.track(evaluation_metric_key)behavior is unchanged.TELEMETRY-CONTRACT.mdsection 4a documents the event contract; section 2 lists the mirrored root-span attributes.Mirrors js-ai-sdk#27. Part of O11Y-1888. Split out of the original combined PR so the two halves could be reviewed apart — the conversation-id half is #42, now merged, so this targets
main.Review follow-ups
gen_ai.evaluation.explanationis emitted only when the judge's own handler captures content.ProviderHandlernow carriescapture_content, set bycreate_handleralongsideprovides_for, so the client core can apply the handler's content decision without reaching into its closure. All six handler factories pass it through. Removing it outright would have left the conversation view's badge tooltip and evaluation banner — both of which read this field — permanently blank. The reasoning always reaches the caller injudge_results.float(score)replaced with a non-raising finite-number guard.float()was a new raise site sitting ahead of the existingclient.track(evaluation_metric_key, …)call, so a judge returning"0.9 (high)"silently killed the metric this PR claims is unchanged — and inrun_judgeit escaped uncaught, breaking that function's documented "returnsNone" contract.end()call, so the deferred end no longer stamps release time and inflate the span by the tracking and parsing work in between.Verified in staging
Real judge AI Config (
chriss-test-judge, Anthropic /claude-sonnet-4-5) attached tochriss-test-configwithsampling_rate: 1, indefault/staging. The judge'sinvoke_agentspan carries:with the explanation present because this run had
capture_contenton; with capture off it is absent. Both directions verified.Rendered with the judge-score UI change, pinned via
frontend-version=f413f8a22:https://ld-stg.launchdarkly.com/projects/default/ai-configs/chriss-test-judge/monitoring?env=staging&selected-env=staging&chartTypes=Tokens%2CSatisfaction%2CGenerations%2CTime%20to%20generate%2CError%20rate%2CTime%20to%20first%20token%2CCosts%2CEvaluator%20metrics&related_resource=eyJ0eXBlIjoiY29udmVyc2F0aW9uIiwiY29udmVyc2F0aW9uSWQiOiJqdWRnZS1leHBsLXB5LTRlNzcyOGZmIiwidHJhY2VJZCI6ImI0NzYwYThhYjQ2MmI2YzViOTk0MDQ5NzE4Njk1ODI1IiwidGltZXN0YW1wIjoiMjAyNi0wOC0yMFQyMDo1OToxNC4wNzI1MDhaIiwiY2FuR29CYWNrIjp0cnVlfQ%3D%3D&frontend-version=f413f8a22
Without that frontend pin the score does not render: the conversation view's existing judge readers expect Vega's
agent.judge.*shape rather than the semconvgen_ai.evaluation.*emitted here. Tracked in O11Y-1905.Reviewer notes
The
span.endpatch is the part worth scrutiny. Nothing breaks today — judges only take the blocking path — but it is the one place the SDK mutates a live span's lifecycle, and span lifecycle is this SDK's worst historical surface. Three reviewers independently suggested the deeper fix: let the judge own its own span rather than mutating a handler-owned one. Deliberately not done here; it's a design decision that deserves its own call.Found while verifying — not fixed here
Python's BYOC path calls
_setup_telemetry(opts.get("sdkKey", "byoc"), …), so a caller who passes a pre-built client viainit_client(client=…)without also passingsdkKeygets every span stampedhighlight.project_id: "byoc". The ingest cannot resolve that, so all telemetry is silently dropped. TypeScript's BYOC path falls back toprocess.env.LD_SDK_KEY. Pre-existing and unrelated to this PR; needs its own ticket.Test plan
uv run pytest— 1083 pass, 11 skippeduv run mypy packages/*/src,ruff check,ruff format --checkcleaninvoke_agentspan after the handler has already ended itscore.labelis not invented; non-numeric scores are rejected without raisingend()call, not release (test fails if the freeze is removed)Note
Overview
Judge scores now land as a
gen_ai.evaluation.resultspan event (plus mirrored attributes) on the judge'sinvoke_agentspan, so conversation-turn score badges can render. Existingtrack(evaluationMetricKey)is unchanged.with_judge_evaluationdelaysspan.end()until the score is parsed, becauseexecute_and_trackreturns after the handler has already ended the span. End time is frozen at the handler'send()so tracking/parsing does not inflate duration. Only a finite numeric score is recorded; junk scores skip the event without raising.Judge reasoning is not exported unless the judge handler was built with
capture_content.ProviderHandlernow surfaces that flag so the core can apply the same content gate. Callers still get reasoning injudge_results.Reviewed by Cursor Bugbot for commit 8a66365. Bugbot is set up for automated code reviews on this repo. Configure here.